CrowdTruth for Free Input Tasks: Person Annotation in Video

In this tutorial, we will apply CrowdTruth metrics to a free input crowdsourcing task for Person Annotation from video fragments. The workers were asked to watch a video of about 3-5 seconds and then add tags that are relevant for the people that appear in the video fragment. The task was executed on FigureEight. For more crowdsourcing annotation task examples, click here.

To replicate this experiment, the code used to design and implement this crowdsourcing annotation template is available here: template, css, javascript.

This is a screenshot of the task as it appeared to workers:

A sample dataset for this task is available in this file, containing raw output from the crowd on FigureEight. Download the file and place it in a folder named data that has the same root as this notebook. Now you can check your data:


In [1]:
import pandas as pd

test_data = pd.read_csv("../data/person-video-free-input.csv")
test_data.head()


Out[1]:
_unit_id _created_at _id _started_at _tainted _channel _trust _worker_id _country _region ... e_volumechange_gold e_waiting_gold hiddeninput_gold imagelocation imagetags keyframeid_gold keywords_gold subtitles subtitletags videolocation
0 1856665485 9/1/2018 18:50:31 4023167828 9/1/2018 18:50:04 False prodege 1 3587109 CAN NS ... NaN NaN NaN https://joran.org/ct/entity.admin.unit.2649/85... industry__c0_###_grinder__c1_###_production__c... NaN NaN Italian astronaut samantha cristoforetti uploa... Italian__0_###_astronaut__1_###_samantha__2_##... https://joran.org/ct/entity.admin.unit.2649/85...
1 1856665485 9/1/2018 19:14:27 4023199796 9/1/2018 19:11:49 False prodege 1 11131207 CAN ON ... NaN NaN NaN https://joran.org/ct/entity.admin.unit.2649/85... industry__c0_###_grinder__c1_###_production__c... NaN NaN Italian astronaut samantha cristoforetti uploa... Italian__0_###_astronaut__1_###_samantha__2_##... https://joran.org/ct/entity.admin.unit.2649/85...
2 1856665485 9/1/2018 19:14:27 4023199802 9/1/2018 19:14:02 False elite 1 44242038 USA FL ... NaN NaN NaN https://joran.org/ct/entity.admin.unit.2649/85... industry__c0_###_grinder__c1_###_production__c... NaN NaN Italian astronaut samantha cristoforetti uploa... Italian__0_###_astronaut__1_###_samantha__2_##... https://joran.org/ct/entity.admin.unit.2649/85...
3 1856665485 9/1/2018 20:00:28 4023268757 9/1/2018 19:59:35 False keeprewarding 1 44637936 CAN NS ... NaN NaN NaN https://joran.org/ct/entity.admin.unit.2649/85... industry__c0_###_grinder__c1_###_production__c... NaN NaN Italian astronaut samantha cristoforetti uploa... Italian__0_###_astronaut__1_###_samantha__2_##... https://joran.org/ct/entity.admin.unit.2649/85...
4 1856665485 9/1/2018 20:59:38 4023382276 9/1/2018 20:58:37 False elite 1 43852630 USA FL ... NaN NaN NaN https://joran.org/ct/entity.admin.unit.2649/85... industry__c0_###_grinder__c1_###_production__c... NaN NaN Italian astronaut samantha cristoforetti uploa... Italian__0_###_astronaut__1_###_samantha__2_##... https://joran.org/ct/entity.admin.unit.2649/85...

5 rows × 70 columns

Declaring a pre-processing configuration

The pre-processing configuration defines how to interpret the raw crowdsourcing input. To do this, we need to define a configuration class. First, we import the default CrowdTruth configuration class:


In [2]:
import crowdtruth
from crowdtruth.configuration import DefaultConfig

Our test class inherits the default configuration DefaultConfig, while also declaring some additional attributes that are specific to the Person Type/Role Annotation in Video task:

  • inputColumns: list of input columns from the .csv file with the input data
  • outputColumns: list of output columns from the .csv file with the answers from the workers
  • annotation_separator: string that separates between the crowd annotations in outputColumns
  • open_ended_task: boolean variable defining whether the task is open-ended (i.e. the possible crowd annotations are not known beforehand, like in the case of free text input); in the task that we are processing, workers pick the answers from a pre-defined list, therefore the task is not open ended, and this variable is set to False
  • annotation_vector: list of possible crowd answers, mandatory to declare when open_ended_task is False; for our task, this is the list of relations
  • processJudgments: method that defines processing of the raw crowd data; for this task, we process the crowd answers to correspond to the values in annotation_vector

Same examples of possible processing functions of crowd answers are given below:


In [3]:
import nltk
nltk.download('stopwords')
nltk.download('punkt')
nltk.download('averaged_perceptron_tagger')
nltk.download('wordnet')
from nltk.corpus import stopwords
from nltk.stem import WordNetLemmatizer
from nltk.corpus import wordnet
from autocorrect import spell

def correct_words(keywords, separator):
    keywords_list = keywords.split(separator)
    corrected_keywords = []
    
    for keyword in keywords_list:
        
        words_in_keyword = keyword.split(" ")
        corrected_keyword = []
        for word in words_in_keyword:
            correct_word = spell(word)
            corrected_keyword.append(correct_word)
        corrected_keywords.append(" ".join(corrected_keyword))
    return separator.join(corrected_keywords)
    
def cleanup_keywords(keywords, separator):
    keywords_list = keywords.split(separator)
    stopset = set(stopwords.words('english'))
    
    filtered_keywords = []
    for keyword in keywords_list:
        tokens = nltk.word_tokenize(keyword)
        cleanup = " ".join(filter(lambda word: str(word) not in stopset or str(word) == "no" or str(word) == "not", keyword.split()))
        filtered_keywords.append(cleanup)
    return separator.join(filtered_keywords)

def nltk2wn_tag(nltk_tag):
    if nltk_tag.startswith('J'):
        return wordnet.ADJ
    elif nltk_tag.startswith('V'):
        return wordnet.VERB
    elif nltk_tag.startswith('N'):
        return wordnet.NOUN
    elif nltk_tag.startswith('R'):
        return wordnet.ADV
    else:          
        return None

def lemmatize_keywords(keywords, separator):
    keywords_list = keywords.split(separator)
    lematized_keywords = []
    
    for keyword in keywords_list:
        nltk_tagged = nltk.pos_tag(nltk.word_tokenize(str(keyword)))  
        wn_tagged = map(lambda x: (str(x[0]), nltk2wn_tag(x[1])), nltk_tagged)
        res_words = []
        for word, tag in wn_tagged:
            if tag is None:            
                res_word = wordnet._morphy(str(word), wordnet.NOUN)
                if res_word == []:
                    res_words.append(str(word))
                else:
                    if len(res_word) == 1:
                        res_words.append(str(res_word[0]))
                    else:
                        res_words.append(str(res_word[1]))
            else:
                res_word = wordnet._morphy(str(word), tag)
                if res_word == []:
                    res_words.append(str(word))
                else: 
                    if len(res_word) == 1:
                        res_words.append(str(res_word[0]))
                    else:
                        res_words.append(str(res_word[1]))
        
        lematized_keyword = " ".join(res_words)
        lematized_keywords.append(lematized_keyword)
        
    return separator.join(lematized_keywords)


[nltk_data] Downloading package stopwords to
[nltk_data]     /Users/oanainel/nltk_data...
[nltk_data]   Package stopwords is already up-to-date!
[nltk_data] Downloading package punkt to /Users/oanainel/nltk_data...
[nltk_data]   Package punkt is already up-to-date!
[nltk_data] Downloading package averaged_perceptron_tagger to
[nltk_data]     /Users/oanainel/nltk_data...
[nltk_data]   Package averaged_perceptron_tagger is already up-to-
[nltk_data]       date!
[nltk_data] Downloading package wordnet to
[nltk_data]     /Users/oanainel/nltk_data...
[nltk_data]   Package wordnet is already up-to-date!

The complete configuration class is declared below:


In [4]:
class TestConfig(DefaultConfig):
    inputColumns = ["videolocation", "subtitles", "imagetags", "subtitletags"]
    outputColumns = ["keywords"]
    
    # processing of a closed task
    open_ended_task = True
    annotation_vector = []
    
    def processJudgments(self, judgments):
        # pre-process output to match the values in annotation_vector
        for col in self.outputColumns:
            # transform to lowercase
            judgments[col] = judgments[col].apply(lambda x: str(x).lower())
            # remove square brackets from annotations
            judgments[col] = judgments[col].apply(lambda x: str(x).replace('[]','no tags'))
            judgments[col] = judgments[col].apply(lambda x: str(x).replace('[',''))
            judgments[col] = judgments[col].apply(lambda x: str(x).replace(']',''))
            # remove the quotes around the annotations
            judgments[col] = judgments[col].apply(lambda x: str(x).replace('"',''))
            # apply custom processing functions
            judgments[col] = judgments[col].apply(lambda x: correct_words(str(x), self.annotation_separator))
            judgments[col] = judgments[col].apply(lambda x: "no tag" if cleanup_keywords(str(x), self.annotation_separator) == '' else cleanup_keywords(str(x), self.annotation_separator))
            judgments[col] = judgments[col].apply(lambda x: lemmatize_keywords(str(x), self.annotation_separator))
        return judgments

Pre-processing the input data

After declaring the configuration of our input file, we are ready to pre-process the crowd data:


In [5]:
data, config = crowdtruth.load(
    file = "../data/person-video-free-input.csv",
    config = TestConfig()
)

data['judgments'].head()


Out[5]:
output.keywords output.keywords.count output.keywords.unique submitted started worker unit duration job
judgment
4023167828 {u'space': 1, u'astronaut': 1} 2 2 2018-09-01 18:50:31 2018-09-01 18:50:04 3587109 1856665485 27 ../data/person-video-free-input
4023199796 {u'astronaut': 1} 1 1 2018-09-01 19:14:27 2018-09-01 19:11:49 11131207 1856665485 158 ../data/person-video-free-input
4023199802 {u'astronaut': 1, u'italian': 1, u'Samantha': ... 5 5 2018-09-01 19:14:27 2018-09-01 19:14:02 44242038 1856665485 25 ../data/person-video-free-input
4023268757 {u'astronaut': 1, u'float': 1} 2 2 2018-09-01 20:00:28 2018-09-01 19:59:35 44637936 1856665485 53 ../data/person-video-free-input
4023382276 {u'zero gravity': 1} 1 1 2018-09-01 20:59:38 2018-09-01 20:58:37 43852630 1856665485 61 ../data/person-video-free-input

Computing the CrowdTruth metrics

The pre-processed data can then be used to calculate the CrowdTruth metrics:


In [6]:
results = crowdtruth.run(data, config)

results is a dict object that contains the quality metrics for the video fragments, annotations and crowd workers.

The video fragment metrics are stored in results["units"]:


In [7]:
results["units"].head()


Out[7]:
duration input.imagetags input.subtitles input.subtitletags input.videolocation job output.keywords output.keywords.annotations output.keywords.unique_annotations worker uqs unit_annotation_score uqs_initial unit_annotation_score_initial
unit
1856665485 52.10 industry__c0_###_grinder__c1_###_production__c... Italian astronaut samantha cristoforetti uploa... Italian__0_###_astronaut__1_###_samantha__2_##... https://joran.org/ct/entity.admin.unit.2649/85... ../data/person-video-free-input {u'italian flag': 1, u'brunette': 1, u'space':... 47 19 20 0.321703 {u'italian flag': 0.0528723763103, u'brunette'... 0.316273 {u'italian flag': 0.05, u'brunette': 0.05, u's...
1856665486 64.40 man__c0_###_soccer__c1_###_portrait__c2_###_pe... this phenomena is it's massive the phenomena__0_###_massive__1_###_ https://joran.org/ct/entity.admin.unit.2649/85... ../data/person-video-free-input {u'caucasian': 1, u'narrator': 1, u'old': 1, u... 46 16 20 0.529430 {u'caucasian': 0.017688995614, u'narrator': 0.... 0.359572 {u'caucasian': 0.05, u'narrator': 0.05, u'old'...
1856665488 50.70 people__c0_###_man__c1_###_adult__c2_###_portr... around could the lights be coming from lights__0_###_coming__1_###_ https://joran.org/ct/entity.admin.unit.2649/85... ../data/person-video-free-input {u'hairline': 1, u'narrator': 1, u'explain': 1... 39 14 20 0.654680 {u'hairline': 0.0270149153886, u'narrator': 0.... 0.489588 {u'hairline': 0.05, u'narrator': 0.05, u'expla...
1856665489 78.60 water__c0_###_no person__c1_###_ocean__c2_###_... when investigators map the coordinates onto lo... investigators__0_###_map__1_###_coordinates__2... https://joran.org/ct/entity.admin.unit.2649/85... ../data/person-video-free-input {u'map': 2, u'none': 1, u'narrator': 2, u'inve... 35 17 20 0.130519 {u'map': 0.117311843342, u'none': 0.0759857654... 0.092384 {u'map': 0.1, u'none': 0.05, u'narrator': 0.1,...
1856665490 30.75 sky__c0_###_no person__c1_###_power__c2_###_el... the bright lights are part of a bright lights__0_###_ https://joran.org/ct/entity.admin.unit.2649/85... ../data/person-video-free-input {u'satellite': 1, u'wire': 1, u'cable': 1, u'l... 31 14 20 0.212254 {u'satellite': 0.0691645433516, u'wire': 0.050... 0.198429 {u'satellite': 0.05, u'wire': 0.05, u'cable': ...

The uqs column in results["units"] contains the video fragment quality scores, capturing the overall workers agreement over each video fragment. Here we plot its histogram:


In [8]:
import matplotlib.pyplot as plt
%matplotlib inline

plt.hist(results["units"]["uqs"])
plt.xlabel("Video Fragment Quality Score")
plt.ylabel("Video Fragment")


Out[8]:
Text(0,0.5,u'Video Fragment')

The unit_annotation_score column in results["units"] contains the video fragment-annotation scores, capturing the likelihood that an annotation is expressed in a video fragment. For each video fragment, we store a dictionary mapping each annotation to its video fragment-relation score.


In [9]:
results["units"]["unit_annotation_score"].head()


Out[9]:
unit
1856665485    {u'italian flag': 0.0528723763103, u'brunette'...
1856665486    {u'caucasian': 0.017688995614, u'narrator': 0....
1856665488    {u'hairline': 0.0270149153886, u'narrator': 0....
1856665489    {u'map': 0.117311843342, u'none': 0.0759857654...
1856665490    {u'satellite': 0.0691645433516, u'wire': 0.050...
Name: unit_annotation_score, dtype: object

The worker metrics are stored in results["workers"]:


In [10]:
results["workers"].head()


Out[10]:
duration job judgment unit wqs wwa wsa wqs_initial wwa_initial wsa_initial
worker
2171654 79.217391 1 23 23 0.020596 0.119212 0.172771 0.025087 0.104635 0.239759
3587109 17.320000 1 25 25 0.210532 0.358709 0.586915 0.089067 0.204887 0.434714
4316379 23.320000 1 25 25 0.151733 0.315378 0.481114 0.049273 0.148899 0.330915
5861591 35.000000 1 4 4 0.090409 0.245694 0.367972 0.162530 0.284655 0.570972
6339764 52.952381 1 21 21 0.010286 0.077361 0.132954 0.033095 0.114312 0.289517

The wqs columns in results["workers"] contains the worker quality scores, capturing the overall agreement between one worker and all the other workers.


In [11]:
plt.hist(results["workers"]["wqs"])
plt.xlabel("Worker Quality Score")
plt.ylabel("Workers")


Out[11]:
Text(0,0.5,u'Workers')